/**
* Copyright (C) 2013 Open WhisperSystems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.whispersystems.bithub.controllers;
import com.codahale.metrics.annotation.Timed;
import com.coinbase.api.exception.CoinbaseException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.commons.net.util.SubnetUtils;
import org.apache.commons.net.util.SubnetUtils.SubnetInfo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.bithub.auth.GithubWebhookAuthenticator.Authentication;
import org.whispersystems.bithub.client.CoinbaseClient;
import org.whispersystems.bithub.client.GithubClient;
import org.whispersystems.bithub.client.TransferFailedException;
import org.whispersystems.bithub.config.RepositoryConfiguration;
import org.whispersystems.bithub.entities.Commit;
import org.whispersystems.bithub.entities.PushEvent;
import org.whispersystems.bithub.entities.Repository;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;
import javax.ws.rs.Consumes;
import javax.ws.rs.FormParam;
import javax.ws.rs.HeaderParam;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.core.MediaType;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import io.dropwizard.auth.Auth;
/**
* Handles incoming API calls from GitHub. These are currently only
* PushEvent webhooks.
*
* @author Moxie Marlinspike
*/
@Path("/v1/github")
public class GithubController {
private static final String GITHUB_WEBOOK_CIDR = "192.30.252.0/22";
private static final String MASTER_REF = "refs/heads/master";
private final Logger logger = LoggerFactory.getLogger(GithubController.class);
private final SubnetInfo trustedNetwork = new SubnetUtils(GITHUB_WEBOOK_CIDR).getInfo();
private final CoinbaseClient coinbaseClient;
private final GithubClient githubClient;
private final Map<String, String> repositories;
private final BigDecimal payoutRate;
public GithubController(List<RepositoryConfiguration> repositories,
GithubClient githubClient,
CoinbaseClient coinbaseClient,
BigDecimal payoutRate)
{
this.coinbaseClient = coinbaseClient;
this.githubClient = githubClient;
this.repositories = new HashMap<>();
this.payoutRate = payoutRate;
for (RepositoryConfiguration repository : repositories) {
this.repositories.put(repository.getUrl().toLowerCase(),
repository.getMode().toUpperCase());
}
}
@Timed
@POST
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Path("/commits/")
public void handleCommits(@Auth Authentication auth,
@HeaderParam("X-Forwarded-For") String clientIp,
@FormParam("payload") String eventString)
throws IOException, UnauthorizedHookException, TransferFailedException, CoinbaseException
{
authenticate(clientIp);
PushEvent event = getEventFromPayload(eventString);
if (!repositories.containsKey(event.getRepository().getUrl().toLowerCase())) {
throw new UnauthorizedHookException("Not a valid repository: " +
event.getRepository().getUrl());
}
if (!event.getRef().equals(MASTER_REF)) {
logger.info("Not a push to master: " + event.getRef());
return;
}
Repository repository = event.getRepository();
String defaultMode = repositories.get(repository.getUrl().toLowerCase());
List<Commit> commits = getQualifyingCommits(event, defaultMode);
BigDecimal balance = coinbaseClient.getAccountBalance();
BigDecimal exchangeRate = coinbaseClient.getExchangeRate();
logger.info("Retrieved balance: " + balance.toPlainString());
sendPaymentsFor(repository, commits, balance, exchangeRate);
}
private void sendPaymentsFor(Repository repository, List<Commit> commits,
BigDecimal balance, BigDecimal exchangeRate)
{
for (Commit commit : commits) {
try {
BigDecimal payout = balance.multiply(payoutRate);
if (isViablePaymentAmount(payout)) {
coinbaseClient.sendPayment(commit.getAuthor(), payout, commit.getUrl());
}
balance = balance.subtract(payout);
githubClient.addCommitComment(repository, commit,
getCommitCommentStringForPayment(payout, exchangeRate));
} catch (TransferFailedException e) {
logger.warn("Transfer failed", e);
}
}
}
private PushEvent getEventFromPayload(String payload) throws IOException {
ObjectMapper objectMapper = new ObjectMapper();
PushEvent event = objectMapper.readValue(payload, PushEvent.class);
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
validator.validate(event);
return event;
}
private List<Commit> getQualifyingCommits(PushEvent event, String defaultMode) {
List<Commit> commits = new LinkedList<>();
Set<String> emails = new HashSet<>();
for (Commit commit : event.getCommits()) {
logger.info(commit.getUrl());
if (!emails.contains(commit.getAuthor().getEmail())) {
logger.info("Unique author: "+ commit.getAuthor().getEmail());
if (isViableMessage(commit.getMessage(), defaultMode)) {
logger.info("Not a merge commit or freebie...");
emails.add(commit.getAuthor().getEmail());
commits.add(commit);
}
}
}
return commits;
}
private boolean isViableMessage(String message, String defaultMode) {
if (message == null || message.startsWith("Merge"))
return false;
return (!message.contains("FREEBIE") && defaultMode.equals("MONEYMONEY")) ||
(message.contains("MONEYMONEY") && defaultMode.equals("FREEBIE"));
}
private boolean isViablePaymentAmount(BigDecimal payment) {
return payment.compareTo(new BigDecimal(0)) == 1;
}
private String getCommitCommentStringForPayment(BigDecimal payment, BigDecimal exchangeRate) {
if (isViablePaymentAmount(payment)) {
BigDecimal paymentUsd = payment.multiply(exchangeRate).setScale(2, RoundingMode.CEILING);
return "Thanks! BitHub has sent payment of $" + paymentUsd.toPlainString() + "USD for this commit.";
} else {
return "Thanks! Unfortunately our BitHub balance is $0.00, so no payout can be made.";
}
}
private void authenticate(String clientIp) throws UnauthorizedHookException {
if (clientIp == null) {
throw new UnauthorizedHookException("No X-Forwarded-For!");
}
if (!trustedNetwork.isInRange(clientIp)) {
throw new UnauthorizedHookException("Untrusted IP: " + clientIp);
}
}
}